Arrow: Fix direct memory leak in row lineage vectorized readers - #17296
Arrow: Fix direct memory leak in row lineage vectorized readers#17296Neuw84 wants to merge 6 commits into
Conversation
RowIdVectorReader and LastUpdatedSeqVectorReader allocated a fresh BigIntVector from the root allocator on every batch, ignored the reuse parameter, kept no reference to the returned vector and had no-op close() methods. Nothing downstream closes the holders' vectors either, so every batch of a vectorized read that projects _row_id or _last_updated_sequence_number leaked one direct-memory vector. On long-running Spark row-level operations against format-version 3 tables this grows without bound until the executor is killed (the identical workload against a v2 table runs flat, as the lineage readers are never instantiated). The readers now own their result vector like the base reader owns vec: they allocate it lazily, reuse it across batches while its capacity suffices and release it in close(), which also closes the delegate readers. New tests assert the root allocator returns to its baseline after close and fail against the previous implementation.
|
Thanks for your review! |
|
We deployed Iceberg 1.11.0 with commit 2493bb7 from this PR cherry-picked Environment/workload:
Representative observations: Before the fix:
After cherry-picking this PR:
This is a greater than 900x reduction in peak DirectPoolMemory. Container AI Disclosure Model: GPT-5.6 Sol |
| ids == null ? null : ArrowVectorAccessors.getVectorAccessor(idsHolder); | ||
|
|
||
| BigIntVector rowIds = allocateBigIntVector(ROW_ID_ARROW_FIELD, numValsToRead); | ||
| BigIntVector rowIds = resultVector(numValsToRead); |
There was a problem hiding this comment.
When reuse is set, then we should use the vectors from there.
There was a problem hiding this comment.
Good catch that the parameter is ignored.
I followed the base VectorizedArrowReader.read contract, where reuse is only a signal. It never reads reuse.vector() either, it resets its own vec, and both call sites (ColumnarBatchReader, ArrowBatchReader) pass back the holder this same reader returned, so reuse.vector() is the vector we already cache. Keeping the vector in the reader is also what lets close() release it, which is the actual fix: previously nothing owned those vectors.
I've pushed a change that uses reuse as the same signal the base reader uses (null → reallocate, otherwise reuse the cached vector if capacity suffices), so the parameter is no longer ignored.
Happy to instead adopt reuse.vector() literally if you prefer, but then we need guards for constant/dictionary holders and a decision on who closes a vector the reader did not allocate, that ambiguity is what caused the leak.
The lineage readers ignored the reuse argument and always kept their own result vector. Use reuse the same way the parent reader does: a null holder releases the current vector and allocates a new one, a non-null holder keeps it, and it is reallocated only when the batch no longer fits. The vector stays owned by the reader, which is what lets close() release it, so reading the vector out of the caller's holder would leave ownership ambiguous again. New tests cover both signals: passing the previous holder back reuses the same vector without growing allocated memory, and a larger batch grows it while releasing the previous one.
| vec.close(); | ||
| } | ||
|
|
||
| this.vec = allocateBigIntVector(ROW_ID_ARROW_FIELD, numValsToRead); |
There was a problem hiding this comment.
We could allocate batchSize sized vectors, and then we don't need to check the size every time.
There was a problem hiding this comment.
Fixed on the latest push. However, note that we added a new field.
Both readers now track the batch size in setBatchSize (with the same
DEFAULT_BATCH_SIZE fallback the parent uses) and allocate the result vector for a full batch, so
resultVector no longer compares capacities. Growing the batch size releases the undersized vector
so the next read allocates one that fits, which keeps setRowGroupInfo/setBatchSize reuse safe.
|
Don't we have the same problem with |
Both lineage readers sized their result vector for the current batch and compared the capacity on every read. Track the batch size like the parent reader does and allocate for a full batch instead, so reads no longer check the capacity: a shorter final batch fits, and a larger batch size releases the undersized vector so the next read reallocates.
|
Yes, it had exactly the same leak, and it is fixed in this PR. The two readers are changed Thanks for your review!. |
| private final VectorizedReader<VectorHolder> posReader; | ||
| private NullabilityHolder nulls; | ||
| private BigIntVector vec; | ||
| private int resultBatchSize = DEFAULT_BATCH_SIZE; |
There was a problem hiding this comment.
nit: Could we call it batchSize as we do in the other readers?
| if (reuse == null || vec == null) { | ||
| if (vec != null) { | ||
| vec.close(); | ||
| } | ||
|
|
||
| this.vec = allocateBigIntVector(ROW_ID_ARROW_FIELD, resultBatchSize); | ||
| } else { | ||
| vec.setValueCount(0); | ||
| } |
There was a problem hiding this comment.
nit:
This is a bit easier to read
| if (reuse == null || vec == null) { | |
| if (vec != null) { | |
| vec.close(); | |
| } | |
| this.vec = allocateBigIntVector(ROW_ID_ARROW_FIELD, resultBatchSize); | |
| } else { | |
| vec.setValueCount(0); | |
| } | |
| if (reuse != null && vec != null) { | |
| vec.setValueCount(0); | |
| } else { | |
| if (vec != null) { | |
| vec.close(); | |
| } | |
| this.vec = allocateBigIntVector(RowIdVectorReader.ROW_ID_ARROW_FIELD, resultBatchSize); | |
| } |
| import org.apache.iceberg.arrow.ArrowAllocation; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| public class TestLineageVectorReaders { |
There was a problem hiding this comment.
package private.
Maybe call it TestVectorizedArrowReader - this is where I would look for any test for VectorizedArrowReader
| private static final int NUM_BATCHES = 64; | ||
|
|
||
| @Test | ||
| public void testRowIdReaderReleasesMemoryOnClose() { |
There was a problem hiding this comment.
package private, and remove the test from the test method name
| this.vec = null; | ||
| } | ||
|
|
||
| this.resultBatchSize = (batchSize == 0) ? DEFAULT_BATCH_SIZE : batchSize; |
There was a problem hiding this comment.
This is very strange to me.
Do you happen to know why we create 0 sized vector, and nulls?
Should we do this the other way around, and create DEFAULT_BATCH_SIZE stuff if the user sets 0?
There was a problem hiding this comment.
For context on where the pattern comes from: PositionVectorReader.setBatchSize has the same ordering (holder sized from the argument, batchSize resolved afterwards), and the parent VectorizedArrowReader.setBatchSize resolves the value but sizes NullabilityHolder from the resolved field only later in read. Happy to clean up PositionVectorReader in this PR too if you want it consistent, or leave it for a follow-up since it is unrelated to the leak.
There was a problem hiding this comment.
We might want to ask @amogh-jahagirdar to review.
He might have more context as he was the one who added the last readers.
Resolve the batch size before it is used, so a zero batch size no longer sizes the nullability holder and the result vector differently. Name the field batchSize like the other readers in this class, invert the branch in resultVector as suggested, and rename the test to TestVectorizedArrowReader with package private visibility and method names without the test prefix.
|
Addressed all your comments |
|
@amogh-jahagirdar, if you have some time please look at the comments. I discovered a leak (fixed in this PR) but we have some doubts on the previous logic on the Arrow readers for row lineage readers. Let us know!, my opinion is that if the change ( from your comments) is big, lets merge this as is critical ( basically V3 on Structured Streaming is broken), I will open another one for what is suggested... if it is small change after you clarification I will implement the changes on this one. |
PositionVectorReader sized its nullability holder from the batch size argument but resolved a zero batch size to DEFAULT_BATCH_SIZE only afterwards, so a zero batch size paired a default sized vector with an empty holder. NullabilityHolder.isNullAt indexes its array without a bounds check, so every null check on the returned holder then failed while the vector reported a full batch of values. Resolve the batch size first and size the holder from the resolved value, as the row lineage readers now do. New tests read a batch after setting a zero batch size and assert the holder is sized like the vector for both the position reader and the row id reader.
|
Have been tinkering a little bit more on that 0. I went looking for where a zero batch size can come from, because the answer decides whether the A zero batch size is reachable, and only ever from an explicit caller. There is no validation
But a zero batch size cannot actually work end to end. In int numValuesToRead = (int) Math.min(nextRowGroupStart - valuesRead, batchSize);
...
valuesRead += numValuesToRead;with What the guard does buy is avoiding a much worse failure, and that is where the ordering matters. public byte isNullAt(int index) {
return isNull[index];
}and both So my read is: the guard is worth keeping as defence in depth, the inconsistency was the real
Let me know your opinions, happy apply the changes on those classes in this pull request. |
RowIdVectorReader and LastUpdatedSeqVectorReader allocated a fresh BigIntVector from the root allocator on every batch, ignored the reuse parameter, kept no reference to the returned vector and had no-op close() methods. Nothing downstream closes the holders' vectors either, so every batch of a vectorized read that projects _row_id or _last_updated_sequence_number leaked one direct-memory vector.
On long-running Spark row-level operations against format-version 3 tables this grows without bound until the executor is killed (the identical workload against a v2 table runs flat, as the lineage readers are never instantiated).
The readers now own their result vector like the base reader owns vec: they allocate it lazily, reuse it across batches while its capacity suffices and release it in close(), which also closes the delegate readers. New tests assert the root allocator returns to its baseline after close and fail against the previous implementation.
AI Disclosure
Model: Claude Fable
Platform/Tool: Kiro
Human Oversight: reviewed
Relates to #17241